Repository navigation
Conversation
added 30 commits
October 5, 2026 20:19
Handle the WebView media permission requests in the Tauri shell, and add the macOS Info.plist usage strings and entitlements that recording needs.
Add the takes API: chunked upload of audio and camera parts, recording tracks that own takes, alignment from the capture sidecar, and takes as lanes in the mix export. Exports now pass a limiter at -1 dBFS so summed lanes cannot clip. A take with a camera recording can be rendered to MP4 over the backing mix.
Add the Record button and recording panel, recording tracks with a voice or instrument input each, take folders with one lane per take, a live waveform while recording, and a Recording tab in Settings for the input, camera and latency test. Stems that were not extracted are hidden from the mixer. Includes the e2e and node tests for these flows.
Add the camera to the record flow, upload the camera recording with the take, and a "Make video" action that renders the take over the backing mix and downloads the MP4. Includes the camera delay nudge, the video strings in every language, and the e2e and node tests.
The desktop shell grants the mic without saving the grant, so each page load started with nameless devices and a new track's picker read No input found until a track was armed. In the desktop app only, open the mic once per page to read the names when a track picker is shown or a track is added.
A Playwright spec that attaches to a running desktop app over CDP and is skipped unless STEMDECK_DESKTOP_CDP is set, so CI never runs it. Also more origin rule cases and the verified WebView2 behaviour in mediaperm.rs.
WAV and FLAC keep the -1 dBFS sample-peak limiter, bit-identical below it. MP3, OGG and both MP4 renders now limit at -1.5 dBTP with 4x oversampling, because the encoder adds peak of its own: measured on real songs, the AAC in video.mp4 still decoded at +0.4 dBFS with clipped samples. AAC is written without intensity stereo, which sent single blocks up to 2.7 dB over the encoder's input. Render cache version 4.
The video block now counts the camera parts sent. When the server holds fewer, the end of the recording is missing, so the take is kept without its video instead of with a short one. After one part fails for good the recorder stops sending the rest: a long take queued hundreds of parts that were each refused, and Stop waited minutes for them.
The camera file starts with the first frame captured after MediaRecorder.start(), but its time on the take's clock came from the recorder's start event, which fires 11 to 61 ms after that frame depending on the codec and the main thread's load. The recorder now reads the capture times of the preview's frames and uses the first one after start(), moved onto the audio stamp clock (perfMsAtFrame0 runs a measured 30 ms behind the capture of the sound). Measured against Chromium's own A/V recorder with fake devices: placement error -18 to +32 ms before (21 takes), -4.5 to +2.4 ms after (9 takes, MP4, VP9 and VP8). Engines without capture times keep the start event, and the sidecar says which.
A 3 s take made a video of the whole song. The video now covers the take by default, from its count-in to where it stopped, with the backing playing throughout; "Whole song" stays a choice next to Make video (remembered per browser, translated). The server takes span "take" or "song", validated, "take" by default. When the camera started before the video's start, the render cut the front and restarted the timestamps at 0, which moved the whole camera up to one frame early (25 ms measured on a take). Frames are now shifted first and cut after, so each lands on the nearest frame of the 30 fps grid. Every lane is cut at the same sample, so the take keeps its place. A running render can be stopped from the take's row.
Chromium's file-based fake camera and microphone play a flash and a burst; the take goes through the real recorder, upload and render, and the flash-to-burst distance in the MP4 must match Chromium's own A/V recorder over the same tracks. Also checks the offset's sign and size and that the take's sound is in the video to the sample. Every spec that opens fake devices now mutes the browser.
The card read a mono 22 kHz buffer cut at 180 s. It now uses ffmpeg ebur128 on the full stereo file at its own rate, with the true peak. Songs analysed before are measured again once, when first opened, tracked by a loudness_version field.
Jobs separated before peaks.json existed, or made by hand, opened on the streaming engine and swapped to the full decode once the empty peaks came back. The first /peaks request now builds the file (one streamed pass per stem, about 1 s for a three minute song), and a first take saved on such a job brings the stems into peaks.json instead of writing the take alone.
A job without peaks started the chunked engine and destroyed it moments later for the full decode. A take started in that moment recorded into the closed context. The engine is now picked once the peaks answer is known; the peaks fetch begins with the job fetch, so this rarely waits.
Record waits for the song's engine to be ready and binds to it. A take whose engine goes away mid-take stops there and keeps what it captured; Stop no longer waits forever on a closed context. A take that never read the song's clock is judged by the audio it holds: over half a second it is kept, placed where the playhead stood, and the panel says so in every language. Before, it was deleted as too short however long it was.
The chunked engine kept every source node it scheduled until pause, seek or destroy, and each node held its five second buffer: memory grew 2.1 MB/s on a six stem song and was freed only at Stop. A node now removes itself when it ends. Measured with CDP on two real songs over three minutes: 2.09 MB/s before, flat after (48 to 61 MB).
Native BiquadFilterNodes in an effects slot between each lane's gain and its analyser, and a master slot before the destination, shared by both engines. Smoothed parameters, crossfaded structure changes, bypass that is bit-exact once its fade has run. EQ saved per song in the mix store, applied when a song opens, and sent to the export as fx=.
fx_filters computes the Web Audio biquad coefficients in Python and hands them to ffmpeg's biquad filter in double precision. The mixdown, video.mp4 and Make video renders take a strictly validated fx= value: lane EQ first in each lane, master EQ before the export limiter. A flat EQ leaves the graph untouched. Render cache version 5. Golden vectors from Chromium's own filters hold the browser, the Python maths and an ffmpeg render to each other.
previewLaneEq plays another EQ on a lane (an A/B compare, a soloed band) and null goes back to the saved one; nothing reaches the store or the export. Engine graph comments name the effects slots.
A small EQ tab at the bottom-left of each waveform lane opens the lane's EQ panel as an extra row right under the lane, in the mixer column and the waveform column at the same fixed height, so the rows stay aligned. Open state is kept per song and lane; the tab shows when the EQ is on or bypassed; the panel row has a saved on/bypass switch. Panel contents load on first open. The waves column clips without being a scroll container, so the tab and the panel stick to the visible area at any zoom.
- The export menu reads export_formats from /api/health. With ogg false the OGG button stays visible but aria-disabled, with a tooltip and a line in the menu giving the reason. A click does nothing, and a selected OGG falls back to WAV. Unknown leaves OGG offered. - New string export.oggUnavailable and an extended help.exportFormat Guide sentence in all ten tables (and the pt-PT variant). - tests/e2e/export-ogg.spec.mjs: disabled with its reason without libvorbis, enabled with it, offered when unknown. stubExportFormats in helpers.mjs fixes the capability; spec-map.json maps the new module. Closes #824
The candidate-path test now runs on Linux and Windows too, and the test module only imported env on macOS, so those test targets did not compile.
On Windows ffprobe_beside looks for ffprobe.exe, so the test wrote a file the code never read.
) Closes #824 ## Product change (flagged) - **libvorbis is no longer required.** Without it StemDeck starts and works; only OGG export is unavailable. - The export menu shows OGG disabled with the reason "OGG export is unavailable: it needs an FFmpeg with Vorbis support." - A forced OGG request (`mixdown.ogg`, `stems/all.zip?format=ogg`) answers 422 with an actionable message, no render started. ## What changed - Shell: `REQUIRED_FFMPEG_ENCODERS` = pcm_s16le, flac, libmp3lame, aac. libvorbis moves to `OPTIONAL_FFMPEG_ENCODERS`. - Backend: `app/pipeline/audio_encoders.py` probes `ffmpeg -encoders` once per binary (cached on `ffmpeg_identity()`). - `/api/health` gains `export_formats`: `{"wav","mp3","flac","ogg": bool}` or `null` when unknown. No paths. - Download errors name the URL and say whether the server was reached (HTTP 429 and other HTTP errors) or the binary was rejected. curl runs `--silent`, so no progress meter in the message. - The macOS setup error ends with the next step: `brew install ffmpeg`, `ffmpeg-full` for OGG, `STEMDECK_FFMPEG`, Rosetta where relevant. - Guide: `help.exportFormat` mentions the greyed-out OGG, all ten tables plus pt-PT. ## New macOS order (Apple Silicon and Intel) 1. `STEMDECK_FFMPEG` (error if it fails). `STEMDECK_FFMPEG_URL` skips step 3 and downloads only from that URL. 2. Data-folder pair that passes the check and is native. 3. Installed pair that passes, one with libvorbis first: - Apple Silicon: `/opt/homebrew/bin`, `/opt/homebrew/opt/ffmpeg-full/bin`, `/usr/local/bin`, `/opt/local/bin`, PATH. Intel-only binaries skipped. - Intel: `/usr/local/bin`, `/usr/local/opt/ffmpeg-full/bin`, `/opt/local/bin`, PATH. 4. Data-folder pair that runs only via Rosetta (kept, not downloaded over). 5. Download: shaka per-architecture primary (now passes), then evermeet only on Intel or with Rosetta. Each source staged separately, installed only after the checks. ## What did not change - Windows and Linux: a system FFmpeg on PATH still needs libvorbis to win over the download. Their downloads include libvorbis, so they pass as before. - On Windows and Linux a data-folder pair without libvorbis now passes the check instead of being re-downloaded. - Linux curl download errors (runtime pack and FFmpeg) get the new wording. Retries are unchanged. - No retries added. Pinned URLs and hashes unchanged. `uv.lock` untouched, runtime id unchanged. No new dependencies. ## Risks - A user with Homebrew's plain ffmpeg gets no OGG until they install `ffmpeg-full` and delete the data-folder pair. Setup does not re-run once a pair passes. - The installed-pair search runs fixed paths it did not run before (`/usr/local/bin`, MacPorts). ## Checks (macOS arm64, ffmpeg 9.0.2 without libvorbis) - `cargo fmt --check`, `cargo test` (179 passed), clippy with the macos-check flags: pass. `clippy --tests -D warnings`: only the 4 existing errors. - pytest: 3333 passed, 15 skipped (the 5 OGG renders skip here). ruff, ruff format, bandit (no medium or high): clean. i18n coverage: clean. - Playwright: `export-ogg` (new), `export-menu`, `help-mode` pass. Local full suite: 690 passed, 11 failed. Every failure passes on rerun or also fails on plain big-release (trash-restore x3, footer-time, karaoke, grid-value-box). PR CI e2e shards: green. - PR CI: Linux, Windows and macOS Rust Check all green. - Real check against an isolated backend: health reports `ogg: false`, WAV/MP3/FLAC mixdowns and the MP3 zip export, OGG returns 422. ## Not tested - A real first launch on an Apple Silicon Mac without ffmpeg, an Intel Mac with ffmpeg in `/usr/local/bin`, and the Rosetta cases. The decision logic is unit-tested for each.
Checks the counter, every lane's stepper, the audio graph's routing and the saved record agree in Practice, Advanced and Karaoke, after switching back and after a reload. Also a record saved by 0.20, a reopen inside the save debounce, a record read after the engine is up, speed and mutes, and that switching leaves no extra AudioContext or pitch chain. The transpose spec's reopen test asserted the key went back to 0, which only held because the reopen beat the debounced save.
The counter, the engine and the saved record could disagree when a song was opened again: - A record saved by 0.20 has each lane's absolute key but no global key, so the counter opened at 0 over lanes still playing transposed. The global key is now read back from the lanes when the record has none (pitchBus.savedGlobalPitch). - Opening a song inside the 300 ms save debounce read the record from before the change; the late write then stored it behind a screen and an engine at 0. The pending write is flushed before the read. - The engine's ready handler pushed whatever was in memory when the record had not been read yet. Gains, lane keys, EQs, speed and the global key now wait for the record, and the key controls stay off until it is in. Closes #781
Closes #781. ## Root cause On 0.20 the global key was never saved: reopening a song restored each lane's absolute key from the store (audio still -2) but reset the counter to 0. big-release saves `__global.pitch`, which fixes a newly saved song, but three paths still let the counter, the engine and the saved record disagree: - A record saved by 0.20 has lane keys and no global key: counter 0 over lanes at -2 (the reporter's data after upgrading). - Opening a song inside the 300 ms save debounce read the old record; the late write then stored the key behind a counter and engine at 0. - The engine's ready handler pushed in-memory state before the record was read (desktop reads it over IPC), and nothing re-pushed it. ## What changed - `pitchBus.savedGlobalPitch`: the global key comes from the record, or from its lanes when the record has none. - `loadMixIntoState` flushes the pending write for that record before reading it. - `player.js`: gains, lane keys, EQs, speed and global key are pushed after the record is in; the key controls stay disabled until then. - New `transpose-switch-song.spec.mjs` (Practice, Advanced, Karaoke; global and per-lane key; reload; 0.20 record; debounce; late record; speed and mutes; no AudioContext growth). The audio check reads the graph routing, not only the engine's reported value. - `transpose.spec` reopen test expected 0, which only held because of the debounce race; it now expects the key to stay. ## What did not change - Performance mode has no key control. Click and count-in stay unpitched by design. Loop region is still reset when you open a song. - The slow-speed value (`stemdeck:slow-speed`) is per browser, not per song: a song saved at 0.75x reopened after the slow value changed plays 0.75x with no speed button lit.
Five presets (Classic, Woodblock, Rimshot, Soft, Cowbell), each with an accent, a group-start and a beat voice, synthesised procedurally (sines, seeded noise through a band-pass, the original click's exponential envelope). The table lives once, as strict JSON inside static/js/clickSounds.js; app/pipeline/click_sounds.py parses that block and runs the same formulas in numpy. Classic is the old click: its renders are byte-identical to big-release. Presets are level-matched to Classic by EBU R128 loudness of a 16 s 4/4 loop (all -27.8 LUFS, woodblock -27.7), with every voice peak under 0.97 at full click volume. tests/fixtures/click_sounds.json pins both renderers to one set of measurements per preset, level and rate (length, peak position and level, 1 ms RMS envelope, spectral centroid, sample sum).
- click_sound on mixdown, video, stems/all.zip and the take render body, checked against the preset whitelist (422 otherwise), default classic. It is only a table key and a cache-key suffix, never a path or argument. - stems/all.zip?click=1 adds <song>_click.<format>: rendered from the beat grid (user edits included) with the mix's rate, accents, grouping and sound, at the stems' rate, channels and exact decoded length (also for MP3-stored songs), from sample 0. Gain is what the click contributes to a mix export (click_gain, -3 dB per channel from the mono-to-stereo conversion), so the zip sums to the mix. Ends in the export limiter for the format. No count-in: zip stems are not delayed. - Without click=1 the zip is unchanged.
A Click sound select in the click track options popover, filled from the preset table. The choice is a global preference saved with the other click settings (stemdeck:metronome), reaches the live click and the count-in, and every export URL (sent only when it is not Classic, so Classic exports keep their URL and cache entry). The live click now plays each preset voice from an AudioBuffer rendered once per sound, level and context rate: one node per click instead of an oscillator and a gain. Changing the sound re-voices the clicks already queued at their own times and leaves the one sounding to finish, so the switch lands on the next beat without a pop, a lost beat or a leaked node. Include click track now also adds the click to the stems .zip; its tooltip and Guide sentence say so. New Guide row for the selector. All ten tables (and pt-PT where the wording differs) translated. Closes #805
…d selector (#831) Closes #805 ## What changed - **Click as its own stem.** With Include click track ticked, Export All Stems adds `<song>_click.<format>` to the .zip (wav, mp3, flac or ogg, same as the stems). Unticked: the zip is exactly as before. - **Click sound selector** in the click options popover: Classic (default, today's sound), Woodblock, Rimshot, Soft, Cowbell. Each has an accent, a group-start and a beat voice. Global preference, saved with the other click settings (`stemdeck:metronome`). Used by the live click, the count-in, mix and region exports, the click stem, the MP4 export and take renders (`click_sound` in the render body). - **One preset table** as strict JSON inside `static/js/clickSounds.js`. `app/pipeline/click_sounds.py` parses that block and runs the same synthesis in numpy (sines with optional exponential glide, seeded xorshift32 noise through an RBJ band-pass, the original click's two exponential ramps). - **Drift gate**: `tests/fixtures/click_sounds.json` holds per-voice measurements (length, peak index and level, 1 ms RMS envelope, power-weighted spectral centroid, sample sum) for every preset, level and rate (44.1/48 kHz). pytest asserts Python still produces it; `tests/js/click-sounds.test.mjs` asserts the browser synthesis matches within tolerance. Same pattern as `click_levels.json`, because the two CI jobs cannot run both languages. - Live click: one `AudioBufferSourceNode` per click from a cached voice buffer (was an oscillator plus a gain). A sound change re-voices queued clicks at their own times and lets the sounding one finish. - API: `click_sound` on `mixdown.{ext}`, `video.mp4`, `stems/all.zip`, take render; whitelisted against the table, 422 otherwise, default `classic`. Used only as a table key and a cache-key suffix. `stems/all.zip` also takes `click`, `click_mult`, `click_accent`, `click_gain`, `click_groups`. ## What did not change - No custom user sample. Adding one later: a preset entry with `"kind": "sample"` and a `renderVoice` branch for it on each side (both refuse any kind but `synth` today); the selector, whitelist and fixture already read the table. - Classic is byte-identical to big-release (sha256 fingerprints of four renders in the tests), and a Classic export keeps its old URL and cache key. - No count-in in the zip (see below). Phone UI untouched: it has no click control. ## Zip layout ``` <song>_drums.wav <song>_bass.wav ... <song>_click.wav ``` `click` cannot collide: no stem is named that (checked at import), and takes are `<label>_<take id>`. ## Gain rule The click stem carries what the click contributes to a mix export: `click_gain` and the mix graph's mono-to-stereo conversion (-3 dB per channel). The zip at unity sums to the mix export within 4 LSB (tested). Ends in the format's export limiter (bit-transparent below -1 dBFS for WAV/FLAC, true-peak for MP3/OGG). ## Alignment guarantee Rendered from the beat grid (user edits included) with the mix's rate, accent, grouping and sound, from sample 0, at the reference stem's rate and channels and its exact decoded frame count (also for MP3-stored songs). Real check, 30 s 120 BPM file, real separation, MP3-stored: every file 1,323,000 frames / 30.000000 s / 44.1 kHz / 2 ch (wav and mp3 zips); 60 of 60 click onsets on the beats.json times, 0 samples off, for Classic and Cowbell. ## Count-in Mix exports only, as today. Zip stems start at the song's first sample and are not re-encoded or delayed, so there is nowhere before them for a count-in; adding one only to the click would misalign it. ## Level matching EBU R128 integrated loudness of a 16 s 4/4 loop at 120 BPM: all presets -27.8 LUFS (Woodblock -27.7), max voice peak 0.968 at full click volume. Tested within 0.5 LU and under 0.99 peak. ## Risks - Listening quality of the four new presets is not verified by ear. - WebKit (desktop macOS) not run: the live click now relies on AudioBuffer playback instead of oscillators. - Found, not fixed: the mix export's click is 3 dB below the live click (ffmpeg's mono-to-stereo upmix in amix). The stem follows the mix. - A beat past beats.json `duration` is in the stem but dropped from the mix's click (pre-existing; durations match on real songs). ## Tests - pytest: 3402 passed, 16 skipped (OGG without libvorbis here). - `npm run test:js`, i18n coverage clean, bandit clean, ruff clean, `uv.lock` unchanged. - Playwright full suite: 700 passed; failures are the known big-release ones (trash-restore x3, streamer-media, footer-time, grid-value-box) plus recording "with a count-in", which passed 3 of 3 on rerun. - New: `tests/test_click_sounds.py`, `tests/js/click-sounds.test.mjs`, `tests/e2e/click-sound.spec.mjs`.
A failed split left the song done with one Vocals stem, a bare 500 and
vocal_split "error", so nothing could say what went wrong.
- The worker reports a code with its error line, from the step that
failed: dependency_missing, model_unavailable, out_of_memory,
worker_failed. The parent adds timeout and cancelled.
- The job keeps vocal_split_reason (persisted) and to_state carries a
fixed safe sentence, vocal_split_message. No stderr, path or
traceback reaches the client.
- A record saved mid-split restores as error/interrupted, not a
"running" that refused every retry with 409.
- A cancel only applies to the run it was sent to, including one still
waiting for the pipeline lock.
- Setup warmup and real splits remember the outcome in the data
folder; /api/health reports vocal_split {available, reason},
unavailable only for a missing dependency on the same package
versions, so the #778 repair or an update switches it back on.
Choosing Lead + Backing and getting one Vocals stem used to say nothing. - A failed split shows a notice: Lead + Backing could not be made, why in one line from the server's code, and Try again, which runs the same vocal-split endpoint on the same song (no re-import, same queue and cancel behaviour). A card in the notification centre keeps it. A cancel shows nothing. - The Stems popover and Karaoke's Separate say why too, including for a song whose last split failed before a restart. - /api/health vocal_split: when a dependency is missing, Lead + Backing in the Extract row, the popover's split and Karaoke's Separate are shown off with the reason, like OGG without libvorbis. Imports then ask for Combined; the saved choice comes back once it is available. - Strings in all ten tables (plus pt-PT where it differs), a Guide row for the new Try again, a Playwright spec and a spec-map rule. Closes #832
Closes #832 Likely cause on the reporter's install: the Linux CPU package shipped a CUDA torchvision (#778, fixed in 0.21.0). This PR fixes the second bug it exposed: a failed split ended with one Vocals stem and no word about it. ## What changed - **Failure codes**: the worker reports the failed step; the job keeps `vocal_split_reason` (persisted) plus a fixed safe `vocal_split_message`. The failed request's 500 body carries the same. No stderr, path or traceback reaches the client. - `dependency_missing`, `model_unavailable`, `out_of_memory`, `worker_failed`, `timeout`, `cancelled`, `interrupted` - **Notice**: Lead + Backing could not be made, why in one line, and **Try again** (same `vocal-split` endpoint, same song, no re-import). Notification card keeps it. Cancel shows nothing. - **Stems popover and Karaoke** say why, also after a restart. - **Startup visibility**: warmup and real splits record the outcome; `/api/health` reports `vocal_split {available, reason}`. A missing dependency shows Lead + Backing off with its reason (Extract row, popover split, Karaoke Separate), like OGG without libvorbis. Tied to package versions, so the #778 repair or an update turns it back on. - A record saved mid-split restores as `error/interrupted` instead of `running` (which 409'd forever). A cancel applies only to its own run, including one waiting for the pipeline lock. - i18n in all ten tables, Guide row for Try again, spec-map rule. ## What did not change - Separation model, Demucs worker, packaging, `uv.lock`. - Mobile UI keeps its existing toast. - The split intent is still client-side only: an import queued with Lead + Backing loses it if the page reloads before the job finishes (not fixed here). ## Tests - pytest: every failure class through the real worker and endpoint, restart, retry, capability, warmup. - Playwright `vocal-split-failure.spec.mjs`: notice, Try again, cancel, popover reason, disabled and enabled option. - Local: pytest 3440 passed; full Playwright 719 passed, 5 failed (trash-restore x3, streamer-media, grid-value-box: known on big-release). ## Risks - Worker error line is now a JSON object; the parent still reads the old string form. - Classification is by phase plus text patterns; a crash with no error line falls back to text and may misread.
An unmapped file under static/ or app/, and the shared modules that used to be in the map's full list, now add a fixed core set of 17 specs on top of smoke and the mapped specs. The whole suite runs only for a change to how the suite runs. uv.lock and pyproject.toml add the core set. The selector takes --mode full|scoped from the workflow.
…to big-release e2e.yml holds the steps once. ci.yml calls it for pull requests (scoped unless the base is main) and for pushes to main and releases (full). e2e-full.yml calls it in full mode on every push to big-release, nightly on big-release, and by hand on any ref. A new push to a pull request cancels its older e2e run; nothing else is cancelled. Each shard has a 30 minute timeout. Closes #835
…g-release (#836) Closes #835 ## What changed - **PR to `main`, push to `main`, release:** full suite, as before. - **PR to any other base:** smoke + changed specs + mapped specs. A hub module (former `full` source entries: `main.js`, `state.js`, `mixer.js`, `catalog.js`, `index.html`, `app/api/jobs.py`, `stems.py`, `app/core/**`, ...) or an unmapped file under `static/` or `app/` now adds a fixed **core set** (17 specs) instead of everything. `uv.lock` and `pyproject.toml` add core too. - **Full suite on a non-main PR** only for a change to how the suite runs: `playwright.config.mjs`, `tests/e2e/helpers*`, `serve.sh`, `seed.py`, `package*.json`, the three e2e workflows, the selector, `spec-map.json`, or unmapped code under `tests/e2e/`. - **New `e2e-full.yml`:** full suite on every push to `big-release`, nightly (04:17 UTC) on `big-release`, and `workflow_dispatch` with a `ref` input. - **New reusable `e2e.yml`** (`workflow_call`, inputs `mode`, `base-ref`, `checkout-ref`) holds the steps once. Same pinned SHAs, `permissions: {}`, no secrets. - **Concurrency:** a new push to a PR cancels that PR's older e2e run. Pushes, releases, nightly and manual runs are never cancelled. `timeout-minutes: 30` per shard. ## What did not change - Application code, specs, `uv.lock`, package files. Three shards, `--pass-with-no-tests`. - Check names gain a prefix: `frontend-e2e / frontend-e2e (1)` to `(3)`. A reusable workflow always prefixes the caller job. No branch protection or ruleset requires these names today (checked). ## Real diffs, before and after | Diff | Files | Before | After | |---|---|---|---| | #806 (queue.js, catalog.js) | 5 | FULL 105 | 30 (core), includes queue-progress | | #820 | 7 | FULL 105 | 59 (core) | | #821 | 2 | 47 | 47 | | #823 | 8 | 5 | 5 | | #828 (uv.lock) | 4 | 5 | 22 (core) | | #819, #830, #831 | 7 to 19 | FULL 105 | FULL 105 (edit `spec-map.json`) | | #829 | 19 | FULL 105 | FULL 105 (edits `helpers.mjs`) | | #822 | 33 | FULL 105 | FULL 105 (edits `seed.py`) | | `main...big-release` | 331 | FULL | FULL (PR to main) | `npx playwright test --list <selected>` matches the selector exactly for #806 (30 files, 200 tests) and #820 (59 files, 465 tests). Full suite: 724 tests in 105 files. Core + smoke alone: 159 tests in 22 files. ## Trade-off A non-main PR can pass while a spec outside its core and mapped sets breaks. The push to `big-release` run catches it on the merge commit, minutes later. ## Limits - **The nightly schedule only starts once `e2e-full.yml` reaches `main`.** GitHub runs scheduled workflows from the default branch, and the job checks out `big-release` explicitly for that reason. - Neither concurrency nor the timeout helps a hosted runner GitHub never assigns (queue time does not count toward the timeout). Fix by hand: re-run the job, or `gh run cancel <id>` then `gh run rerun <id> --failed`. - This PR changes `ci.yml`, so its own CI runs the full suite. Expected.
… app plays it
The click is rendered mono. Live, Web Audio up-mixes it into the stereo
master as L = R = M. In exports, amix left the conversion to
libswresample's default mono-to-stereo matrix, which puts it 3.01 dB
lower on each channel. Every export now converts the click with an
explicit pan=stereo|c0=c0|c1=c0 (CLICK_TO_STEREO): the mix and region
export, the count-in, the stems zip click file, the MP4 export and take
renders. The zip still sums to the mix. The click sound and its timing
are unchanged.
A cached mix with a click misses the cache now (":u" in the click part
of the key), so an upgrade never serves the old level.
Closes #833
Closes #833 ## What changed - The rendered click is mono. Every export now takes it to stereo with an explicit `pan=stereo|c0=c0|c1=c0` (`CLICK_TO_STEREO`), so each channel carries the click at its volume, as the app plays it (Web Audio up-mixes it as L = R = M). Before, amix left the conversion to libswresample's default matrix: -3.01 dB per channel. - The stems zip click file uses the same conversion, so the zip still sums to the mix. - A cached mix with a click misses the cache (`:u` in the click part of the key), so nobody is served the old level after upgrading. Mixes without a click keep their keys. ## Render paths touched - `app/pipeline/click_render.py:63` `CLICK_TO_STEREO` - `app/api/stems.py:1956` get_mixdown: mix export (all formats), region export, count-in - `app/api/stems.py:2217` get_video_mixdown (MP4) - `app/api/stems.py:2385` `_render_click_stem` (stems zip click file), docstring updated - `app/api/takes.py:1731` take renders (Streamer Mode video) - `app/api/stems.py:262` mixdown cache key - Phone UI has no click control, so no phone path. ## What did not change - The click sound: the mono render, Classic's four sha256 fingerprints and `tests/fixtures/click_sounds.json` all pass untouched. `click_levels.json` (accent levels, not dB) untouched. - Alignment: on a real export, frame counts and onsets are identical to big-release, and after = before x sqrt(2) within 1.1 LSB. - The live click. ## Levels (Classic, volume 0.6) - Export per channel vs live: -3.01 dB before, 0.00 dB after (mix, region, count-in, zip, MP4, take render). - Real 20 s song, click only: peak -11.11 to -8.10 dBFS, RMS -37.68 to -34.67 dBFS. - Accented click alone at 0.6: peak -7.86 to -4.85 dBFS, still under the -1 dBFS limiter. ## Risks - Every click export is 3 dB louder than in 0.20. The limiter now engages on a click alone from about volume 0.94 (was 1.32). At volume 4 on loud stems: WAV/FLAC peak -1.00 dBFS, MP3 -1.00 dBTP, no sample at full scale, same as before. - On an MP3-stored song, a count-in export on big-release came out mono (stems folded to mono). It is stereo now. ## Tests - New: the export's per-channel level equals the live buffer times the volume (mix, region, count-in in WAV and FLAC; zip; MP4; take render). Each failed on big-release at -3.01 dB. - New: the loudest click (4, accented) on loud stems stays under the ceiling in WAV, FLAC, MP3, OGG, in the mix and the zip. - Updated on purpose: `UPMIX = 1/sqrt(2)` removed from `tests/test_click_sounds.py` (zip alignment, chosen-sound mix, zip-sums-to-mix tests now expect unity). Release note: Exported clicks are now 3 dB louder than in 0.20, so they match what you hear in the app. If you set the click level by ear against an export, you may want to turn the click volume down a little.
A slow Azure Ubuntu mirror (29 kB/s) made the browser system-package and ffmpeg installs run past the 30 minute job limit, so shards were cancelled before any test ran. - Pin the e2e runner to ubuntu-24.04 while ubuntu-latest moves to 26. - scripts/ci/retry.sh: bounded attempts with a per-attempt timeout that kills the whole process group, every attempt logged, exit code kept. Tested by tests/ci/test_retry_sh.sh in the lint job. - Browser download and system packages are separate steps, each with a 10 minute limit; browsers and the downloaded .debs are cached. - ffmpeg comes from a SHA256-pinned BtbN static build, cached and checked on every run, instead of apt; the job fails by name if libvorbis, libmp3lame, aac or flac is missing. - Per-step setup timings and cache hit or miss go to the job summary. Closes #838
…s as dead The PR's own CI hit the slow mirror: two shards spent three 170 s attempts fetching fonts from azure.archive.ubuntu.com at about 40 kB/s. apt only falls back to the next mirror on an error, so retry.sh now tells the command its attempt number, and attempts after the first drop the Azure entry from /etc/apt/apt-mirrors.txt and start on archive.ubuntu.com. The hang test read a killed child as alive in the lint container, where PID 1 does not reap orphans; it now reads the state from /proc. Closes #838
Closes #838 ## What changed - **Runner pinned** to `ubuntu-24.04` for `frontend-e2e` only, with a comment to revisit after the Ubuntu 26 label migration (actions/runner-images#14748). - **`scripts/ci/retry.sh`**: up to 3 attempts, a per-attempt `timeout` that kills the whole process group, pauses of 10 s then 20 s. Every attempt is logged with exit code and duration; a retried success leaves a warning annotation; the last exit code is returned unchanged. `tests/ci/test_retry_sh.sh` (28 checks) runs in the `lint` job. - **Browsers**: `npx playwright install chromium` on its own, cached (`~/.cache/ms-playwright`, keyed on the lockfile's Playwright version), 10 minute step limit. - **System packages**: `playwright install-deps chromium` on its own, run as root through the retry wrapper so the timeout can kill apt. apt gets `Acquire::Retries=3`, http/https timeouts of 20 s and `DPkg::Lock::Timeout=60` via `/etc/apt/apt.conf.d`, so Playwright's own apt-get uses them. The downloaded .debs are cached (keyed on Playwright and image version). Attempts after the first drop `azure.archive.ubuntu.com` from `/etc/apt/apt-mirrors.txt`, so they start on `archive.ubuntu.com`: apt falls back only on an error, never on a trickle. 10 minute step limit. - **ffmpeg**: no longer apt. A static BtbN build (`autobuild-2026-09-30-13-08`, ffmpeg 8.1.3, gpl), pinned by SHA256, cached, and checked against the pin on every run before it is unpacked. The step prints `ffmpeg -encoders | grep -E "libvorbis|libmp3lame"` and fails by name if libvorbis, libmp3lame, aac or flac is missing. - **Job summary**: a table of each setup step's seconds and cache hit, miss or partial, written with `if: always()`, so a step killed by its limit shows as "did not finish". ## Why ffmpeg from BtbN - Static download: GitHub's release CDN, not the Ubuntu mirror that stalled. BtbN keeps month-end builds for about two years and is already the desktop app's Windows source. Has libvorbis, libmp3lame and chromaprint. - Third-party setup action: rejected. It downloads the binary itself, so the checksum pin would live in someone else's code and need a review on every bump. The workflow can pin it directly in a few lines. - apt with cached .debs: still on the slow mirror whenever any of its 87 packages gets an update, which would be a cache miss. - shaka-project static builds (the macOS source): no libvorbis. ## Step limits 10 minutes each. A healthy run takes about 20 s for the system packages and seconds for the rest. The 12-minute installs in #838 were at 29 kB/s; with the .debs cached they should not recur. If they do, failing at 10 minutes and retrying (apt resumes partial downloads) beats using up the whole 30 minute job, which stays as the outer limit. ## What did not change - Job names `frontend-e2e (1)` to `(3)`, the selector, the scoped and full cadence, `e2e-full.yml`. - Specs, app code, `uv.lock`, package files. - Other workflows. The last 200 runs show no slow or failed apt or download step outside `frontend-e2e` (Linux Rust Check's apt step peaks at 72 s, the `test` and `linux-installer` apt steps stay under 60 s), so they are not pinned or changed. ## Evidence Run 39827053220 (first `e2e-full`, 6c3cf58), attempt 1: shard 1 spent 12m23s in `playwright install --with-deps`, then 16m28s in apt's ffmpeg until the job limit. Shard 3: 11m38s, then 17m41s. Shard 2: 22 s and 11 s. The log has "Fetched 20.9 MB in 11min 56s (29.2 kB/s)" from `azure.archive.ubuntu.com`; the Chromium download itself took 9 s. The image was `ubuntu-24.04` 20261012.334.1. Same pattern in runs 39823430200, 39792892907 and 39827062156. ## Trade-offs - A retry hides a flaky step for up to 3 attempts. The warning annotation and the log show it. - The pin must be revisited after the migration. - e2e now runs ffmpeg 8.1.3 instead of Ubuntu's 6.1. That matches the desktop builds, not the Docker image. ## Results on this PR - First push (3bb1874): the slow mirror came back. One shard in each of runs 39843695691 and 39843702780 spent three 170 s attempts fetching fonts from Azure at about 40 kB/s, and failed at 9 minutes instead of 30. That is why the second commit drops Azure on a retry. - Second push (ca231fb), cold .deb cache, run 39854523178: shard 2's first attempt timed out at 170 s on Azure, the second dropped Azure and finished in 13 s. All 3 shards green. - Warm caches, manual `e2e-full` run 39854515365: browsers 1 s (hit), system packages 10 to 39 s (hit), ffmpeg 7 to 10 s (hit, checksum and unpack). All 3 shards green. - Encoders printed in every shard: libvorbis and libmp3lame present; `export-ogg` specs pass. ## Not verified - A step reaching its 10 minute limit. The per-attempt timeout fired for real; the step limit did not. - Caches from this branch are not visible to other PRs. They start helping once the change is on big-release.
With the library open, the tightest one-line controls row is 910px and the strip had 671px at the desktop app's default 1280x820 window (415px at the 1024x720 minimum). Past the "tight" level the row scrolled sideways with no scrollbar showing, cutting Global key mid-label and hiding the click track behind Export Mix. Same in WKWebView and Chromium. footerFit.js gets a fourth level, "rows": whole clusters wrap onto a second line, packed left, dividers hidden. When Master and Export Mix beside the strip would push it to more lines, they move to their own line under it, kept only when that makes the controls row shorter. The LUFS value no longer loses digits to the Peak qualifier beside it: the qualifier gives up its width first. New spec footer-no-clip checks 1280, 1024, 1100 and 1440 for clipped, overlapping or covered footer controls. Closes #842
The node model of the strip had no parent, so the new collapse-stack check threw before any assertion ran. It now has the controls row around the strip, wraps clusters at the rows level, and checks that a strip too short for the tight row wraps, that nothing runs past its edge, and that the tight/rows boundary settles and opens back up. Closes #842
…#843) Closes #842 ## What changed - `footerFit.js`: a fourth collapse level, `rows`, after `tight`. Whole clusters wrap onto a second line (packed left, dividers hidden). Before it, a row still too wide after `tight` scrolled sideways inside the strip with no scrollbar. - At `rows`, Master and Export Mix move to their own line under the strip (`collapse-stack` on `.footer-row-controls`) only when that makes the controls row shorter. At 1024 with the library open this is 2 cluster lines plus the actions, instead of 4 cluster lines. - Analysis cards: `.meta-card-value` no longer shrinks before its qualifier, so LUFS "-25.2" stops showing as "-25...". The key card keeps shrinking and wrapping as before. - New spec `footer-no-clip` at 1280x820, 1024x720, 1100x820, 1440x900: no footer control clipped by the strip or the viewport, no two overlapping, nothing under Export Mix, each control is what the hit test finds at its centre. Plus one test that a wide window undoes `rows`. Fails on plain big-release at all four sizes. - `tests/js/footer-fit.test.mjs`: the strip model gets its controls row and wraps at `rows`; new checks for the tight/rows boundary and reopening. ## What did not change - No control's behaviour, markup, label or Guide entry. Tab order is the DOM order, unchanged. - The `wrap`, `click` and `tight` levels, and the hysteresis (#633). - Analysis card labels still ellipsize at 1024 and 1100 (Dynamic Range, Tempo Stability, the presence labels). That is the existing floor for those labels, not touched here. ## Strategy The footer already measures in JS because the room depends on the library sidebar, not the window (#586), so this extends that ladder rather than adding a media or container query. Wrapping whole clusters was the only option that covers the deficit: at 1280 the tight row is 910px for 671px, and no spacing or caption change frees 239px. Collapsing a group behind a button would move controls behind a click; wrapping hides nothing and costs one control line of lane height. ## Verified Same backend, a 20 s sine imported through POST /api/jobs, studio in DAW mode, library open. Footer controls clipped or overlapping: | Size | WKWebView before | WKWebView after | Chromium before | Chromium after | |---|---|---|---|---| | 1280x820 | 9 clipped | 0 | 9 clipped | 0 | | 1024x720 | 16 clipped | 0 | 16 clipped | 0 | | 1100x820 | 13 clipped | 0 | 13 clipped | 0 | | 1440x900 | 4 clipped | 0 | 4 clipped | 0 | Both engines measured identical cluster widths (Inter), so the cause was width, not WKWebView text metrics. ## Risks - The footer is taller when it wraps (54px to 118px for the controls at 1280), so the lanes get less height. - Not verified in WebKitGTK, WebView2 or a packaged app. Only WKWebView (snapshot harness, not the app) and Chromium. - A single cluster wider than the whole strip (a window below the 1024 minimum, with the library open) still scrolls.
| unfinished = await _create(c) | ||
| r = await c.delete(f"/api/jobs/{JOB}/takes/{tid}") | ||
| assert r.status_code == 200 and r.json() == {"take_id": tid, "status": "deleted"} | ||
| assert (await c.delete(f"/api/jobs/{JOB}/takes/{unfinished}")).status_code == 200 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
The whole of 0.21.0 on one branch: 208 commits ahead of
main. There is nonext-releasebranch this time, so every fix PR went intobig-releaseand this pull request goes straight tomain. It stays a draft until I have built and tried it on every desktop platform.Merging it publishes the Docker
:edgeimage, so I will merge it at release time only. Please merge with a merge commit, not a squash, so the per-feature history and theCloseslines in the commit messages survive.Read this first: every desktop install does one full download for 0.21.0. The Python dependencies changed (a security update, and Python 3.11 is now the minimum), so the runtime id changes and the in-app updater will not offer this release. The new download carries a fresh runtime. See "Dependencies" below.
What is in it
Modes
Karaoke
Performance
Studio
Click track
Stem storage: a change in default behaviour
STEMDECK_STEM_FORMATandSTEMDECK_STEM_MP3_KBPS.Imports, library and playback
Desktop
STEMDECK_WEBKIT_DMABUF=on|offoverrides it.Hardening
Dependencies
certifiandpilloware updated, together withurllib3,anyio,fsspecandhydra-core, and the matching entries are removed from.trivyignore. The security scan passes without them.requires-pythonis now>=3.11; every shipped build already used 3.12, and Python 3.10 is no longer supported.uv.lock; the lock is what CI tests and the scan reads, and it sets the runtime id.Licence
Fixed since the first version of this description
#771, #772, #773, #774, #775, #778, #780, #781, #783, #785, #788, #789, #791, #793, #794, #797, #798, #799, #803, #810, #812, #814, #824, #827, #832, #833 and #842 are fixed or answered, each checked against its tests. #805 (click file and sounds) is fixed here and closes when this merges. #790 is fixed on the Python side; its Windows part is not run yet (see below). The README is updated; the release notes are written at release time.
How it was checked
ac72e17) all 19 checks pass, including the full browser suite.pytest3402 passed, 16 skipped (the OGG tests skip where the FFmpeg has no Vorbis encoder). Last full run on Windows, 2026-10-11, on an earlier commit: 3178 passed, 5 skipped, 0 failed.Measured earlier on my machine, not repeated on the current tip:
What is not tested yet
/usr/local/bin.backendjob.rs, part of Cancelling, deleting or quitting during a video export does not always stop it #790).Open before the release
Notes for review
main.rs, newmediaperm.rs,backendjob.rsanddmabuf.rs, the macOSInfo.plistandEntitlements.plist,Cargo.tomlandCargo.lock).uv.lockchanged, so the runtime id changed (py3.12-585d1341908f343b): existing desktop installs are not offered the in-app update and download the full package once.